Анализ поведения пользователей в мобильном приложении "Ненужные вещи"¶
Контекст:
Приложение "Ненужные вещи" представляет собой сервис в формате доски-объявлений для продажи товаров. Различными путями, пользователь ищет и выбирает необходимый ему товар, после чего связывается с продавцом и обсуждает детали сделки. Целевое событие в рамках исследования - просмотр контактов продавца.
Цели исследования:
Исследовать данные о поведении пользователей, с целью последующего поиска механизмов повышения вовлеченности клиентов приложения.
Получить на основе поведения пользователей гипотезы о том как можно было бы улучшить приложение с точки зрения пользовательского опыта.
Описание данных:
В датасете содержатся данные пользователей, впервые совершивших действия в приложении после 7 октября 2019 года.
Колонки в /datasets/mobile_sources.csv:
- userId — идентификатор пользователя,
- source — источник, с которого пользователь установил приложение.
Колонки в /datasets/mobile_dataset.csv:
- event.time — время совершения,
- user.id — идентификатор пользователя,
- event.name — действие пользователя.
Виды действий:
- advert_open — открыл карточки объявления,
- photos_show — просмотрел фотографий в объявлении,
- tips_show — увидел рекомендованные объявления,
- tips_click — кликнул по рекомендованному объявлению,
- contacts_show и show_contacts — посмотрел номер телефона, contacts_call — позвонил по номеру из объявления,
- map — открыл карту объявлений,
- search_1 — search_7 — разные действия, связанные с поиском по сайту,
- favorites_add — добавил объявление в избранное.
Основные этапы исследования:
1. Загрузка данных и ознакомление с ними;
2. Предобработка данных:
- Работа с типами данных;
- Приведение названий столбцов к разумному виду;
- Проверка данных на пропуски и дубликаты;
- Проверка аномалий и выбросов;
- Объединение датасетов;
- Создание дополнительных столбцов для целей исследования.
3. Исследовательский анализ данных:
3.1 Разделим пользователей на совершивших целевое действие (целевых) и нет (нецелевых);
3.2 Рассмотрим, из каких источников приходят пользователи?
3.3 Пришедшие из каких источников пользователи чаще смотрят контакты продавца?
3.4 Проанализируем, какие события пользователи совершают чаще? Как различаются события для целевых/нецелевых пользователей;
3.5 Изучим конверсию для пользователей в разрезе источников;
3.6. Изучим конверсию для рекомендованных объявлений. Как она различается для пользователей из разных источников?
3.7 Рассмотрим сессии пользователей. Сколько в среднем сессий приходится на пользователя? Какова средняя длинна сессии? Сколько действий совершает пользователь за сессию?
3.8 Проанализируем метрику DAU для всех пользователей приложения.
4. Основные вопросы исследования:
4.1. Проанализируем влияние событий на совершение целевого события:
- В разрезе сессий, определим сценарии поведения пользователей, рассмотрим, как наиболее эффективно пользователи доходят до просмотра контаков.
- Построим воронки по основным сценариям в разрезе уникальных пользователей.
4.2. Рассчитаем относительную частоту событий в разрезе двух групп пользователей - кто смотрел контакты и кто не смотрел. Сделаем выводы.
5. Проверим статистические гипотезы:
5.1 Одни пользователи совершают действия tips_show и tips_click , другие — только tips_show . Проверьте гипотезу: конверсия в просмотры контактов различается у этих двух групп.
5.2 Конверсия в просмотры контактов у пользователей кто совершил только tips_show и кто совершил tips_show и favorites_add различается.
5.3 Конверсия в просмотры контактов у пользователей кто добавил объявление в избранное выше, чем у тех кто не добавил.
6. Сформируем выводы и рекомендации для заказчика.
Загрузка данных и ознакомление с ними¶
#подключим нужные модули и библиотеки
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats as st
import math as mth
import plotly.express as px
from plotly import graph_objects as go
from plotly.subplots import make_subplots
import requests
from tqdm import tqdm
# сохраним данные в переменные
mob_sources = pd.read_csv('/datasets/mobile_sources.csv')
mob_dataset = pd.read_csv('/datasets/mobile_dataset.csv')
def describe (row):
for n in row:
n.info(),display(n.head(5), ('*'*50))
return
Вывод:
- В обоих датасетах нет пропусков. Датасет с источиками трафика (mob_sources) содержит 4293 строк, датасет с действиями пользователей (mob_dataset) содержит 74197 строк.
- В обоих датасетах следует привести название столбцов к рабочему виду;
- Следует просмотреть датасеты на дубли, а также проверить неявные дубли в столбцах event.name и source;
- В столбце event.time датасета mob_dataset следует разобраться в форматом даты и привести столбец к необходимому типу данных;
- Датасеты следует объединить по идентификатору пользователя.
Предобработка данных¶
Приведем название столбцов к рабочему виду¶
mob_sources.columns = mob_sources.columns.str.replace("userId", "user_id")
print(mob_sources.columns)
mob_dataset.columns = mob_dataset.columns.str.replace(".", "_", regex=True)
mob_dataset.columns
Index(['user_id', 'source'], dtype='object')
Index(['event_time', 'event_name', 'user_id'], dtype='object')
Разберемся с датой и временем¶
mob_dataset['event_time'] = pd.to_datetime(mob_dataset['event_time'])
# округлим время до секунд
mob_dataset['event_time'] = mob_dataset['event_time'].dt.round('S')
mob_dataset.head(5)
| event_time | event_name | user_id | |
|---|---|---|---|
| 0 | 2019-10-07 00:00:00 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 1 | 2019-10-07 00:00:01 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 2 | 2019-10-07 00:00:02 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| 3 | 2019-10-07 00:00:07 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 4 | 2019-10-07 00:00:56 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
Посмотрим, за какой период у нас данные
display(f"Данные с {mob_dataset['event_time'].min()} по {mob_dataset['event_time'].max()}")
'Данные с 2019-10-07 00:00:00 по 2019-11-03 23:58:13'
Объединим датасеты¶
# объединим по user_id
df = mob_dataset.merge(mob_sources, on=['user_id'], how='left')
df.head(5)
| event_time | event_name | user_id | source | |
|---|---|---|---|---|
| 0 | 2019-10-07 00:00:00 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | 2019-10-07 00:00:01 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 2 | 2019-10-07 00:00:02 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 3 | 2019-10-07 00:00:07 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 4 | 2019-10-07 00:00:56 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
Поищем дубликаты¶
df.duplicated().sum()
1118
duplicate_rows = df[df.duplicated()]
duplicate_rows
| event_time | event_name | user_id | source | |
|---|---|---|---|---|
| 396 | 2019-10-07 11:00:20 | tips_show | fb667205-a708-4693-832d-363a30022cfc | yandex |
| 422 | 2019-10-07 11:10:40 | map | ed13f6f0-08f4-4561-852e-456580f7a40d | yandex |
| 423 | 2019-10-07 11:10:40 | map | ed13f6f0-08f4-4561-852e-456580f7a40d | yandex |
| 425 | 2019-10-07 11:10:41 | map | ed13f6f0-08f4-4561-852e-456580f7a40d | yandex |
| 430 | 2019-10-07 11:11:32 | map | ed13f6f0-08f4-4561-852e-456580f7a40d | yandex |
| ... | ... | ... | ... | ... |
| 73011 | 2019-11-03 18:02:20 | photos_show | 6e623c43-e219-4c27-bc29-5180d9250f8d | other |
| 73427 | 2019-11-03 20:09:47 | tips_show | a0944ccf-5813-427a-966e-b146d4e9adbc | |
| 73678 | 2019-11-03 21:10:40 | photos_show | 06edf71c-b725-47dc-acfe-0c78f079fe8f | yandex |
| 73839 | 2019-11-03 21:45:22 | photos_show | 1af9ffcd-2c77-4de0-9d35-3ff30604c9bd | |
| 74028 | 2019-11-03 22:41:01 | tips_show | 16a5371c-152f-48d8-86fe-5636a931316b | yandex |
1118 rows × 4 columns
print('Процент дубликатов составляет:', df.duplicated().sum()/df.shape[0]*100)
Процент дубликатов составляет: 1.506799466285699
# удалим дубликаты
df = df.drop_duplicates().reset_index(drop=True)
Посмотрим неявные дубли
display(df['source'].unique())
display(df['event_name'].unique())
array(['other', 'yandex', 'google'], dtype=object)
array(['advert_open', 'tips_show', 'map', 'contacts_show', 'search_4',
'search_5', 'tips_click', 'photos_show', 'search_1', 'search_2',
'search_3', 'favorites_add', 'contacts_call', 'search_6',
'search_7', 'show_contacts'], dtype=object)
В названии действий есть действия 'show_contacts' и 'contacts_show', обозначающие одно и то же. Посмотрим, как часто они встречаются и объединим их.
df['event_name'].sort_values().value_counts()
tips_show 39892 photos_show 9372 advert_open 6145 contacts_show 4302 map 3760 search_1 3489 favorites_add 1414 search_5 1049 tips_click 811 search_4 701 contacts_call 538 search_3 521 search_6 460 search_2 324 search_7 222 show_contacts 79 Name: event_name, dtype: int64
df['event_name'] = df['event_name'].replace('show_contacts', 'contacts_show')
df['event_name'].sort_values().value_counts()
tips_show 39892 photos_show 9372 advert_open 6145 contacts_show 4381 map 3760 search_1 3489 favorites_add 1414 search_5 1049 tips_click 811 search_4 701 contacts_call 538 search_3 521 search_6 460 search_2 324 search_7 222 Name: event_name, dtype: int64
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 73079 entries, 0 to 73078 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_time 73079 non-null datetime64[ns] 1 event_name 73079 non-null object 2 user_id 73079 non-null object 3 source 73079 non-null object dtypes: datetime64[ns](1), object(3) memory usage: 2.2+ MB
Вывод по разделу¶
На этапе предобработки было сделано следующее:
- Датасеты объединены;
- Название столбцов приведены к рабочему виду;
- Столбец с датой приведен к нужному типу данных (datetime64[ns]), время округлено до секунд.
- Удалены дубликаты (1,5% от данных);
- Действие 'просмотр контактов', изначально записанное двумя разными названиями, объединено под одним - 'contacts_show';
- Проверен период данных. Данные представлены с 2019-10-07 00:00:00 по 2019-11-03 23:58:13;
- Размер датасета после предобработки - 73079 строк, 4 столбца.
Исследовательский анализ данных¶
Разделим пользователей на совершивших целевое действие (целевых) и нет (нецелевых)¶
Посмотрим, сколько всего пользователей в датасете
display(f"Всего уникальных пользователей: {df['user_id'].nunique()}")
'Всего уникальных пользователей: 4293'
contacts_yes = df.query('event_name == "contacts_show"')
contacts_yes = contacts_yes['user_id'].to_list()
df['is_target'] = np.where(df['user_id'].isin(contacts_yes), 'Yes', 'No')
users = df.pivot_table(
index = 'is_target',
values = 'user_id',
aggfunc = 'nunique')
users.rename(index={'No':'Нецелевые пользователи', 'Yes':'Целевые пользователи'}, inplace= True)
# построим круговую диаграму
fig = go.Figure(
data=[go.Pie(labels=users.index, values=users['user_id'])])
fig.update_layout(title='Доля пользователей в датасете, совершивших и не совершивших целевое действие')
fig.show()
Вывод:
23% пользователей в датасете совершили целевое действие - просмотр контактов, 77% - нет.
Рассмотрим, из каких источников приходят пользователи?¶
display(f"Источники: {df['source'].unique()}")
"Источники: ['other' 'yandex' 'google']"
sources_all = df.groupby('source')['user_id'].nunique().reset_index(name='count')
fig = go.Figure(
data=[go.Pie(labels=sources_all.source, values=sources_all['count'])])
fig.update_layout(title='Источники трафика по количеству пришедших пользователей')
fig.show()
Пришедшие из каких источников пользователи чаще смотрят контакты продавца?¶
sources_yes = df.query('is_target == "Yes"').groupby('source')['user_id'].nunique().reset_index(name='count')
sources_no = df.query('is_target != "Yes"').groupby('source')['user_id'].nunique().reset_index(name='count')
fig = make_subplots(1, 2, specs=[[{'type':'domain'}, {'type':'domain'}]],
subplot_titles=['Целевые пользователи', 'Нецелевые пользователи'])
fig.add_trace(go.Pie(labels=sources_yes.source, values=sources_yes['count'],
name="Целевые пользователи"), 1, 1)
fig.add_trace(go.Pie(labels=sources_no.source, values=sources_no['count'],
name="Нецелевые пользователи"), 1, 2)
fig.update_layout(title_text='Источники трафика в разрезе целевых и нецелевых пользователей')
fig.show()
Вывод:
Больше всего пользователей приходят из Яндекса (45%), причем пользователи из Яндекса чаще совершают целевое действие
Проанализируем, какие действия пользователи совершают чаще?¶
events = df.pivot_table(
index = 'event_name',
values = 'user_id',
aggfunc = 'count').sort_values(by='user_id', ascending=False).reset_index()
events = events.rename(columns={'user_id':'count'})
fig = px.bar(events, x='event_name', y='count', color='event_name', text ='count')
fig.update_layout(title='Действия пользователей',
xaxis_title='Вид действия',
yaxis_title='Количество действий')
fig.show()
Вывод:
Наиболее часто пользователи видят рекомендованные объявления. Помимо этого, популярные действия - просмотр фотографий объявления, переход на карточку объявления и просмотр контактов.
Посмотрим, какие действия популярны у целевой группы пользователей?
events_yes = df.query('is_target == "Yes"').pivot_table(
index = 'event_name',
values = 'user_id',
aggfunc = 'count').sort_values(by='user_id', ascending=False).reset_index()
events_yes = events_yes.rename(columns={'user_id':'count'})
fig = px.bar(events_yes, x='event_name', y='count', color='event_name', text ='count')
fig.update_layout(title='Действия целевых пользователей',
xaxis_title='Вид действия',
yaxis_title='Количество действий')
fig.show()
Вывод:
Для целевой группы пользователей картина немного другая. Просмотр рекомендованного объявления по-прежнему наиболее популярное действие, однако далее следует просмотр контактов и просмотр фотографий.
Изучим конверсию для пользователей в разрезе источников¶
Посмотрим на соотношение числа просмотров контактов к количеству пользователей в разрезе источников трафика.
source_target = df.query('event_name == "contacts_show"').pivot_table(
index='source',
values='event_name',
aggfunc='count').reset_index()
sources_target_ratio = sources_all.merge(source_target, on='source')
sources_target_ratio = sources_target_ratio.rename(columns={'count':'users','event_name':'contacts_show'})
sources_target_ratio['ratio'] = round((sources_target_ratio['contacts_show']/sources_target_ratio['users'])*100,1)
fig = px.bar(sources_target_ratio, x='source', y='ratio', color='source', text = 'ratio')
fig.update_layout(title='Отношение числа просмотра контактов к количеству пользователей',
xaxis_title='Источник',
yaxis_title='Просмотры к кол-ву пользователей')
fig.show()
Вывод:
Из Яндекса приходит больше всего пользователей, однако соотношение числа просмотров контактов к количеству пользователей ниже, чем у Google.
Оценим конверсию пользователей в разрезе источников трафика. Выделим уникальных пользователей по каждому источнику трафика и посмотрим сколько из них совершили целевое действие. Используем для расчетов созданные ранее переменные.
cr_source = sources_all.merge(sources_yes, on='source')
cr_source = cr_source.rename(columns={'count_x':'count', 'count_y':'target'})
cr_source['cr_ratio'] = round((cr_source['target']/cr_source['count']) * 100, 2)
cr_source
| source | count | target | cr_ratio | |
|---|---|---|---|---|
| 0 | 1129 | 275 | 24.36 | |
| 1 | other | 1230 | 228 | 18.54 |
| 2 | yandex | 1934 | 478 | 24.72 |
fig = px.bar(cr_source, x='source', y='cr_ratio', color='source', text = 'cr_ratio')
fig.update_layout(title='Конверсия пользователей в целевое действие по истоникам',
xaxis_title='Источник',
yaxis_title='Конверсия, %')
fig.show()
Вывод:
При том, что больше пользователей приходит из Яндекса, показатели конверсии в целевое действие у пользователей из Яндекса и Google находятся примерно на одном уровне.
Изучим конверсию для рекомендованных объявлений. Как она различается для пользователей из разных источников?¶
Посмотрим на соотношение числа просмотров контактов к количеству целевых пользователей в разрезе источников трафика.
tips_show = df.query('event_name == "tips_show"')
tips_show_users = tips_show['user_id'].to_list()
tips_show_target = df.query('user_id in @tips_show_users and event_name == "contacts_show"')
tips_show = tips_show.pivot_table(
index='source',
values='user_id',
aggfunc='nunique').reset_index()
tips_show_target = tips_show_target.pivot_table(
index='source',
values='event_name',
aggfunc='count').reset_index()
tips_show_ratio = tips_show.merge(tips_show_target, on='source')
tips_show_ratio = tips_show_ratio.rename(columns={'user_id':'users','event_name':'contacts_show'})
tips_show_ratio['ratio'] = round((tips_show_ratio['contacts_show']/tips_show_ratio['users'])*100,1)
fig = px.bar(tips_show_ratio, x='source', y='ratio', color='source', text = 'ratio')
fig.update_layout(title='Число просмотра контактов к кол-ву пользователей, смотревших рекомендованные объявлений',
xaxis_title='Источник',
yaxis_title='Конверсия, %')
fig.show()
Вывод:
В целом, ситуация схожа с графиком соотношения числа просмотра контактов к общему количеству пользователей. Однако в разрезе рекомендованных объявлений заметнее увеличение числа просмотра контактов для пользователей из Google и снижение для пользователей из Яндекса.
Оценим конверсию пользователей, смотревших рекомендованные объявления.
tips_show_count = df.query('event_name == "tips_show"').pivot_table(
index = 'source',
values = 'user_id',
aggfunc = 'nunique').reset_index()
users_tips_show = df.query('event_name == "tips_show"')['user_id'].to_list()
tips_show_and_target = df.query('user_id in @users_tips_show and event_name == "contacts_show"')
tips_show_and_target = tips_show_and_target.pivot_table(
index = 'source',
values = 'user_id',
aggfunc = 'nunique').reset_index()
cr_tips_show_source = tips_show_count.merge(tips_show_and_target, on='source' )
cr_tips_show_source = cr_tips_show_source.rename(columns={'user_id_x':'count', 'user_id_y':'target'})
cr_tips_show_source['cr_ratio'] = round((cr_tips_show_source['target']/cr_tips_show_source['count']) * 100, 2)
cr_tips_show_source
| source | count | target | cr_ratio | |
|---|---|---|---|---|
| 0 | 651 | 121 | 18.59 | |
| 1 | other | 840 | 127 | 15.12 |
| 2 | yandex | 1310 | 268 | 20.46 |
fig = px.bar(cr_tips_show_source, x='source', y='cr_ratio', color='source', text = 'cr_ratio')
fig.update_layout(title='Конверсия пользователей из просмотра рек. объявлений в целевое действие по истоникам',
xaxis_title='Источник',
yaxis_title='Конверсия, %')
fig.show()
Вывод:
Наибольшую конверсию из рекомендованных объявлений в целевое действие демонстрируют пользователи, пришедшие из Яндекса (20,46%).
Рассмотрим сессии пользователей. Сколько в среднем сессий приходится на пользователя? Какова средняя длинна сессии? Сколько действий совершает пользователь за сессию?¶
Для начала выделим пользовательские сессии.
Будем выделять сессии через тайм-аут сессии, то есть через интервал времени бездействия пользователя, после которого считается, что пользовательская сессия закончилась. Установим, что время тайм-аута сессии составляет 30 минут. В Яндекс.Метрике и Google Analytics по умолчанию тайм-аут сессии как раз составляет 30 минут.
Яндекс.Метрика, дефолтное значение тайм-аута сессии составляет 30 минут.
Google Analytics 4, дефолтное значение аналогично составляет 30 мин. https://support.google.com/analytics/answer/9191807#:~:text=%D0%A7%D1%82%D0%BE%20%D1%81%D1%87%D0%B8%D1%82%D0%B0%D0%B5%D1%82%D1%81%D1%8F%20%D1%81%D0%B5%D0%B0%D0%BD%D1%81%D0%BE%D0%BC,%D1%8D%D1%82%D0%BE%D0%BC%20%D0%BD%D0%B5%20%D0%BE%D0%B3%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%B5%D0%BD%D0%B0.
# отсортируем датафрейм
df = df.sort_values(['user_id', 'event_time'])
# определим разницу в 30 мин
g = (df.groupby('user_id')['event_time'].diff() > pd.Timedelta('30Min')).cumsum()
# создадим группы
df['session_id'] = df.groupby(['user_id', g], sort=False).ngroup() + 1
Сколько в среднем сессий приходится на пользователя?
count_session = df.pivot_table(
index = 'user_id',
values = 'session_id',
aggfunc = 'nunique')
count_session = count_session.rename(columns={'session_id':'count'})
fig = px.histogram(count_session, x='count')
fig.update_layout(title='Распределение сессий',
xaxis_title='Кол-во сессий',
yaxis_title='Кол-во наблюдений')
fig.show()
count_session.describe()
| count | |
|---|---|
| count | 4293.000000 |
| mean | 2.415094 |
| std | 3.536466 |
| min | 1.000000 |
| 25% | 1.000000 |
| 50% | 1.000000 |
| 75% | 3.000000 |
| max | 99.000000 |
np.percentile(count_session, 95)
7.0
Вывод:
Выявлены пользователи с аномальным количеством сессий. Однако в среднем, количесво сессий на пользователя не превышает 3.
Посмотрим на среднюю длинну сессии
session_duration = df.pivot_table(
index = 'session_id',
values = 'event_time',
aggfunc = ['min', 'max'])
session_duration['duration'] = session_duration['max'] - session_duration['min']
session_duration['duration'].describe()
count 10368 mean 0 days 00:12:52.668981481 std 0 days 00:19:51.607952024 min 0 days 00:00:00 25% 0 days 00:00:21 50% 0 days 00:05:35 75% 0 days 00:17:25.250000 max 0 days 05:21:58 Name: duration, dtype: object
Вывод:
Средняя длина сессии находится в районе 6 минут.
session_duration['seconds'] = session_duration['duration'].dt.total_seconds().round()
print(len(session_duration[session_duration['seconds'] == 0]))
print(len(session_duration))
2142 10368
Посмотрим, сколько действий совершает пользователь за сессию?
session_events = df.pivot_table(
index = 'session_id',
values = 'event_name',
aggfunc = 'count')
session_events = session_events.rename(columns={'event_name':'event_count'})
fig = px.histogram(session_events, x='event_count', nbins=200)
fig.update_layout(title='Распределение действий',
xaxis_title='Кол-во действий',
yaxis_title='Кол-во наблюдений')
fig.show()
session_events.describe()
| event_count | |
|---|---|
| count | 10368.000000 |
| mean | 7.048515 |
| std | 9.415075 |
| min | 1.000000 |
| 25% | 2.000000 |
| 50% | 4.000000 |
| 75% | 8.000000 |
| max | 149.000000 |
np.percentile(session_events, 95)
24.0
Вывод:
Зачастую, пользователи совершают не более 24 действий за сессию. Среднее значение лежит около 4 действий за сессию.
Проанализируем метрику DAU для всех пользователей приложения.¶
df_dau = df.copy()
df_dau['event_date'] = df['event_time'].dt.date
dau = df_dau.groupby('event_date')['user_id'].nunique()
dau.describe()
count 28.000000 mean 279.178571 std 46.737291 min 178.000000 25% 238.250000 50% 292.500000 75% 310.500000 max 352.000000 Name: user_id, dtype: float64
fig = go.Figure()
fig.add_trace(go.Box(x=dau))
fig.update_layout(title='DAU (daily active users)',
xaxis_title='Количество пользователей')
fig.show()
Вывод:
В среднем количество активных пользователей в день находится в районе 240 - 300 человек.
Вывод по разделу¶
- Всего уникальных пользователей в датасете: 4293;
- 23% пользователей в датасете совершили целевое действие (981 человек) - просмотр контактов, 77% - нет (3312 человек);
- В датасете содержатся 3 истоника трафика - Яндекс, Google, Другие. Больше всего пользователей приходят из Яндекса (45%), причем пользователи из Яндекса чаще совершают целевое действие.
- Самое распространенное действие - просмотр рекомендованных объявлений. Помимо этого, популярные действия - просмотр фотографий объявления, переход на карточку объявления и просмотр контактов;
- Для целевой группы пользователей просмотр рекомендованного объявления так же наиболее популярное действие, однако далее следует просмотр контактов и просмотр фотографий;
- При том, что больше пользователей приходит из Яндекса, показатели конверсии в целевое действие у пользователей из Яндекса и Google находятся примерно на одном уровне.
- Наибольшую конверсию из рекомендованных объявлений в целевое действие демонстрируют пользователи, пришедшие из Яндекса (20,46%).
- В среднем, на пользователя приходится 3 сессии, а средняя длина сессии находится в районе 6 минут. При этом, пользователи совершают не более 24 действий за сессию. Среднее значение лежит около 4 действий за сессию.
- Метрика DAU показывает, что в среднем количество активных пользователей в день находится в районе 240 - 300 человек.
Основные вопросы исследования¶
Проанализируем влияние событий на совершение целевого события:¶
В разрезе сессий, определим сценарии поведения пользователей, рассмотрим, как наиболее эффективно пользователи доходят до просмотра контаков. Для поиска сценарий будем использовать диаграмму Сэнкей.¶
Сначала, избавимся от повторяющихся событий в рамках сессии.
df_filtered = df.drop_duplicates(subset=['session_id','event_name'], keep='first')
Через функцию, определим пары source-target (пары событий, в которых происходит переход пользователей от одного события (source) к другому (target). Также, пронумеруем эти пары на основании времени наступления события, то есть таким образом получим шаг в рамках диаграммы.
def add_features(df):
"""Функция генерации новых столбцов для исходной таблицы
Args:
df (pd.DataFrame): исходная таблица.
Returns:
pd.DataFrame: таблица с новыми признаками.
"""
# сортируем по id и времени
sorted_df = df.sort_values(by=['user_id', 'event_time']).copy()
# добавляем шаги событий
sorted_df['step'] = sorted_df.groupby('session_id').cumcount() + 1
# добавляем узлы-источники и целевые узлы
# узлы-источники - это сами события
sorted_df['source'] = sorted_df['event_name']
# добавляем целевые узлы
sorted_df['target'] = sorted_df.groupby('session_id')['source'].shift(-1)
# возврат таблицы без имени событий
return sorted_df.drop(['event_name'], axis=1)
# преобразуем таблицу
df_filtered = add_features(df_filtered)
df_filtered.head()
| event_time | user_id | source | is_target | session_id | step | target | |
|---|---|---|---|---|---|---|---|
| 795 | 2019-10-07 13:39:46 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show | No | 1 | 1 | NaN |
| 6471 | 2019-10-09 18:33:56 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | map | No | 2 | 1 | tips_show |
| 6495 | 2019-10-09 18:40:29 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show | No | 2 | 2 | NaN |
| 35878 | 2019-10-21 19:52:31 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show | No | 3 | 1 | map |
| 35885 | 2019-10-21 19:53:39 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | map | No | 3 | 2 | NaN |
Теперь нам нужно определить, какое количество шагов для диаграммы выбрать? Для этого расмотрим как распределяются шаги в пользовательских сессиях.
steps = df_filtered.groupby('session_id')['step'].agg('count')
np.percentile(steps, 99)
6.0
Видим, что в целом за сессию, пользователь совершает не более 6 шагов. Установим это значение в качестве ограничения для построения диаграммы.
df_comp = df_filtered[df_filtered['step'] <= 6].copy().reset_index(drop=True)
Далее, нам необходимо создать индексы для source. Поскольку на каждом следующем шаге target становится source, появляется возможность корректно оценить переход от одного действия к другому, а значит, необходима правильная индексация.
def get_source_index(df):
"""Функция генерации индексов source
Args:
df (pd.DataFrame): исходная таблица с признаками step, source, target.
Returns:
dict: словарь с индексами, именами и соответсвиями индексов именам source.
"""
res_dict = {}
count = 0
# получаем индексы источников
for no, step in enumerate(df['step'].unique().tolist()):
# получаем уникальные наименования для шага
res_dict[no+1] = {}
res_dict[no+1]['sources'] = df[df['step'] == step]['source'].unique().tolist()
res_dict[no+1]['sources_index'] = []
for i in range(len(res_dict[no+1]['sources'])):
res_dict[no+1]['sources_index'].append(count)
count += 1
# соединим списки
for key in res_dict:
res_dict[key]['sources_dict'] = {}
for name, no in zip(res_dict[key]['sources'], res_dict[key]['sources_index']):
res_dict[key]['sources_dict'][name] = no
return res_dict
# создаем словарь
source_indexes = get_source_index(df_comp)
Теперь, необходимо сгенерировать цвета для диаграмы, чтобы лучше были выделены переходы от одного действия (источника) к другому.
def generate_random_color():
"""Случайная генерация цветов rgba
Args:
Returns:
str: Строка со сгенерированными параметрами цвета
"""
# сгенерим значение для каждого канала
r, g, b = np.random.randint(255, size=3)
return f'rgba({r}, {g}, {b}, 1)'
def colors_for_sources(mode):
"""Генерация цветов rgba
Args:
mode (str): сгенерировать случайные цвета, если 'random', а если 'custom' -
использовать заранее подготовленные
Returns:
dict: словарь с цветами, соответствующими каждому индексу
"""
# словарь, в который сложим цвета в соответствии с индексом
colors_dict = {}
if mode == 'random':
# генерим случайные цвета
for label in df_comp['source'].unique():
r, g, b = np.random.randint(255, size=3)
colors_dict[label] = f'rgba({r}, {g}, {b}, 1)'
elif mode == 'custom':
# присваиваем ранее подготовленные цвета
colors = requests.get('https://raw.githubusercontent.com/rusantsovsv/senkey_tutorial/main/json/colors_senkey.json').json()
for no, label in enumerate(df_comp['source'].unique()):
colors_dict[label] = colors['custom_colors'][no]
return colors_dict
colors_dict = colors_for_sources(mode='custom')
Для грамотного отображения данных, создадим словарь со списками данных для диаграммы.
def percent_users(sources, targets, values):
"""
Расчет уникальных id в процентах (для вывода в hover text каждого узла)
Args:
sources (list): список с индексами source.
targets (list): список с индексами target.
values (list): список с "объемами" потоков.
Returns:
list: список с "объемами" потоков в процентах
"""
# объединим источники и метки и найдем пары
zip_lists = list(zip(sources, targets, values))
new_list = []
# подготовим список словарь с общим объемом трафика в узлах
unique_dict = {}
# проходим по каждому узлу
for source, target, value in zip_lists:
if source not in unique_dict:
# находим все источники и считаем общий трафик
unique_dict[source] = 0
for sr, tg, vl in zip_lists:
if sr == source:
unique_dict[source] += vl
# считаем проценты
for source, target, value in zip_lists:
new_list.append(round(100 * value / unique_dict[source], 1))
return new_list
def lists_for_plot(source_indexes=source_indexes, colors=colors_dict, frac=10):
"""
Создаем необходимые для отрисовки диаграммы переменные списков и возвращаем
их в виде словаря
Args:
source_indexes (dict): словарь с именами и индексами source.
colors (dict): словарь с цветами source.
frac (int): ограничение на минимальный "объем" между узлами.
Returns:
dict: словарь со списками, необходимыми для диаграммы.
"""
sources = []
targets = []
values = []
labels = []
link_color = []
link_text = []
# проходим по каждому шагу
for step in tqdm(sorted(df_comp['step'].unique()), desc='Шаг'):
if step + 1 not in source_indexes:
continue
# получаем индекс источника
temp_dict_source = source_indexes[step]['sources_dict']
# получаем индексы цели
temp_dict_target = source_indexes[step+1]['sources_dict']
# проходим по каждой возможной паре, считаем количество таких пар
for source, index_source in tqdm(temp_dict_source.items()):
for target, index_target in temp_dict_target.items():
# делаем срез данных и считаем количество id
temp_df = df_comp[(df_comp['step'] == step)&(df_comp['source'] == source)&(df_comp['target'] == target)]
value = len(temp_df)
# проверяем минимальный объем потока и добавляем нужные данные
if value > frac:
sources.append(index_source)
targets.append(index_target)
values.append(value)
# делаем поток прозрачным для лучшего отображения
link_color.append(colors[source].replace(', 1)', ', 0.2)'))
labels = []
colors_labels = []
for key in source_indexes:
for name in source_indexes[key]['sources']:
labels.append(name)
colors_labels.append(colors[name])
# посчитаем проценты всех потоков
perc_values = percent_users(sources, targets, values)
# добавим значения процентов для howertext
link_text = []
for perc in perc_values:
link_text.append(f"{perc}%")
# возвратим словарь с вложенными списками
return {'sources': sources,
'targets': targets,
'values': values,
'labels': labels,
'colors_labels': colors_labels,
'link_color': link_color,
'link_text': link_text}
# создаем словарь
data_for_plot = lists_for_plot()
Шаг: 0%| | 0/6 [00:00<?, ?it/s] 0%| | 0/14 [00:00<?, ?it/s] 21%|██▏ | 3/14 [00:00<00:00, 26.43it/s] 43%|████▎ | 6/14 [00:00<00:00, 26.85it/s] 64%|██████▍ | 9/14 [00:00<00:00, 26.09it/s] 100%|██████████| 14/14 [00:00<00:00, 25.99it/s] Шаг: 17%|█▋ | 1/6 [00:00<00:02, 1.85it/s] 0%| | 0/15 [00:00<?, ?it/s] 20%|██ | 3/15 [00:00<00:00, 26.25it/s] 40%|████ | 6/15 [00:00<00:00, 25.24it/s] 60%|██████ | 9/15 [00:00<00:00, 25.52it/s] 80%|████████ | 12/15 [00:00<00:00, 23.98it/s] 100%|██████████| 15/15 [00:00<00:00, 24.56it/s] Шаг: 33%|███▎ | 2/6 [00:01<00:02, 1.71it/s] 0%| | 0/15 [00:00<?, ?it/s] 20%|██ | 3/15 [00:00<00:00, 25.27it/s] 40%|████ | 6/15 [00:00<00:00, 25.33it/s] 60%|██████ | 9/15 [00:00<00:00, 26.00it/s] 80%|████████ | 12/15 [00:00<00:00, 24.70it/s] 100%|██████████| 15/15 [00:00<00:00, 25.27it/s] Шаг: 50%|█████ | 3/6 [00:01<00:01, 1.70it/s] 0%| | 0/15 [00:00<?, ?it/s] 20%|██ | 3/15 [00:00<00:00, 26.37it/s] 40%|████ | 6/15 [00:00<00:00, 26.75it/s] 60%|██████ | 9/15 [00:00<00:00, 27.17it/s] 80%|████████ | 12/15 [00:00<00:00, 26.80it/s] 100%|██████████| 15/15 [00:00<00:00, 26.88it/s] Шаг: 67%|██████▋ | 4/6 [00:02<00:01, 1.73it/s] 0%| | 0/14 [00:00<?, ?it/s] 29%|██▊ | 4/14 [00:00<00:00, 32.26it/s] 57%|█████▋ | 8/14 [00:00<00:00, 32.70it/s] 100%|██████████| 14/14 [00:00<00:00, 31.51it/s] Шаг: 100%|██████████| 6/6 [00:02<00:00, 2.17it/s]
Теперь, создадим объект диаграммы.
def plot_senkey_diagram(data_dict=data_for_plot):
"""
Функция для генерации объекта диаграммы Сенкей
Args:
data_dict (dict): словарь со списками данных для построения.
Returns:
plotly.graph_objs._figure.Figure: объект изображения.
"""
fig = go.Figure(data=[go.Sankey(
domain = dict(
x = [0,1],
y = [0,1]
),
orientation = "h",
valueformat = ".0f",
node = dict(
pad = 50,
thickness = 15,
line = dict(color = "black", width = 0.1),
label = data_dict['labels'],
color = data_dict['colors_labels']
),
link = dict(
source = data_dict['sources'],
target = data_dict['targets'],
value = data_dict['values'],
label = data_dict['link_text'],
color = data_dict['link_color']
))])
fig.update_layout(title_text="Диаграмма Сэнкей для действий пользователей", font_size=10, width=1000, height=700)
# возвращаем объект диаграммы
return fig
# сохраняем диаграмму в переменную
senkey_diagram = plot_senkey_diagram()
senkey_diagram.show()
Вывод
Исходя из толщины потоков, отображаемых на диаграмме, можем выделить основные сценарии действий пользователей, приводящих к целевому действию (просмотр контактов):
Сценарий 1. search_1 -- photos_show -- contacts_show
Сценарий 2. tips_show -- contacts_show
Сценарий 3. map -- tips_show -- contacts_show
Cценарий 4. map -- advert_open -- contacts_show
Построим воронки по основным сценариям в разрезе уникальных пользователей.¶
Воронка для сценария search_1 -- photos_show -- contacts_show
col_names = ['event','user_count']
scenario_1 = pd.DataFrame(columns = col_names)
scenario_1['event'] = ['search_1','photos_show', 'contacts_show']
row_1 = []
step_1 = df.query('event_name == "search_1"')
row_1.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()
step_2 = df.query('user_id in @step_1_users and event_name == "photos_show"')
row_1.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()
step_3 = df.query('user_id in @step_2_users and event_name == "contacts_show"')
row_1.append(step_3['user_id'].nunique())
scenario_1['user_count'] = pd.Series(row_1)
scenario_1
| event | user_count | |
|---|---|---|
| 0 | search_1 | 787 |
| 1 | photos_show | 643 |
| 2 | contacts_show | 191 |
from plotly import graph_objects as go
fig = go.Figure(go.Funnel(
y = scenario_1['event'],
x = scenario_1['user_count'],
textposition = "inside",
textinfo = "value+percent initial+percent previous",
marker = {"color": ["#104a5c", "#0f8e54", "#ffa600"],
"line": {"width": [3, 2, 2], "color": ["white", "white", "white"]}},
connector = {"fillcolor": '#a6bddb'},
insidetextfont = {'color': 'white', 'size': 14})
)
fig.update_layout(title='Воронка для сценария search_1 -- photos_show -- contacts_show')
fig.show()
Вывод:
В сценарии search_1 -- photos_show -- contacts_show, на первом шаге находятся 787 пользователей, а 24% пользователей доходят до целевого действия.
Воронка для сценария tips_show -- contacts_show
col_names = ['event','user_count']
scenario_2 = pd.DataFrame(columns = col_names)
scenario_2['event'] = ['tips_show', 'contacts_show']
row_2 = []
step_1 = df.query('event_name == "tips_show"')
row_2.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()
step_2 = df.query('user_id in @step_1_users and event_name == "contacts_show"')
row_2.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()
scenario_2['user_count'] = pd.Series(row_2)
scenario_2
| event | user_count | |
|---|---|---|
| 0 | tips_show | 2801 |
| 1 | contacts_show | 516 |
fig = go.Figure(go.Funnel(
y = scenario_2['event'],
x = scenario_2['user_count'],
textposition = "inside",
textinfo = "value+percent initial+percent previous",
marker = {"color": ["#104a5c", "#ffa600"],
"line": {"width": [3, 2], "color": ["white", "white"]}},
connector = {"fillcolor": '#a6bddb'},
insidetextfont = {'color': 'white', 'size': 14})
)
fig.update_layout(title='Воронка для сценария tips_show -- contacts_show')
fig.show()
Вывод:
Сценарий tips_show -- contacts_show, является наиболее коротким. На первом шаге находятся 2801 пользователь, однако только 18% пользователей доходят до целевого действия.
Воронка для сценария map -- tips_show -- contacts_show
col_names = ['event','user_count']
scenario_3 = pd.DataFrame(columns = col_names)
scenario_3['event'] = ['map', 'tips_show', 'contacts_show']
row_3 = []
step_1 = df.query('event_name == "map"')
row_3.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()
step_2 = df.query('user_id in @step_1_users and event_name == "tips_show"')
row_3.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()
step_3 = df.query('user_id in @step_2_users and event_name == "contacts_show"')
row_3.append(step_3['user_id'].nunique())
scenario_3['user_count'] = pd.Series(row_3)
scenario_3
| event | user_count | |
|---|---|---|
| 0 | map | 1456 |
| 1 | tips_show | 1352 |
| 2 | contacts_show | 275 |
fig = go.Figure(go.Funnel(
y = scenario_3['event'],
x = scenario_3['user_count'],
textposition = "inside",
textinfo = "value+percent initial+percent previous",
marker = {"color": ["#104a5c", "#0f8e54", "#ffa600"],
"line": {"width": [3, 2, 2], "color": ["white", "white", "white"]}},
connector = {"fillcolor": '#a6bddb'},
insidetextfont = {'color': 'white', 'size': 14})
)
fig.update_layout(title='Воронка для сценария map -- tips_show -- contacts_show')
fig.show()
Вывод:
В сценарии map -- tips_show -- contacts_show, на первом шаге находятся 1456 пользователей, при этом 19% пользователей доходят до целевого действия.
Воронка для сценария map -- advert_open -- contacts_show
col_names = ['event','user_count']
scenario_4 = pd.DataFrame(columns = col_names)
scenario_4['event'] = ['map', 'advert_open', 'contacts_show']
row_4 = []
step_1 = df.query('event_name == "map"')
row_4.append(step_1['user_id'].nunique())
step_1_users = step_1['user_id'].to_list()
step_2 = df.query('user_id in @step_1_users and event_name == "advert_open"')
row_4.append(step_2['user_id'].nunique())
step_2_users = step_2['user_id'].to_list()
step_3 = df.query('user_id in @step_2_users and event_name == "contacts_show"')
row_4.append(step_3['user_id'].nunique())
scenario_4['user_count'] = pd.Series(row_4)
scenario_4
| event | user_count | |
|---|---|---|
| 0 | map | 1456 |
| 1 | advert_open | 517 |
| 2 | contacts_show | 86 |
fig = go.Figure(go.Funnel(
y = scenario_4['event'],
x = scenario_4['user_count'],
textposition = "inside",
textinfo = "value+percent initial+percent previous",
marker = {"color": ["#104a5c", "#0f8e54", "#ffa600"],
"line": {"width": [3, 2, 2], "color": ["white", "white", "white"]}},
connector = {"fillcolor": '#a6bddb'},
insidetextfont = {'color': 'white', 'size': 14})
)
fig.update_layout(title='Воронка для сценария map -- advert_open -- contacts_show')
fig.show()
Вывод:
В сценарии map -- advert_open -- contacts_show, на первом шаге находятся 1456 пользователей, при этом 6% пользователей доходят до целевого действия. Это наиболее низкая конверсия среди рассмотренных сценариев.
Рассчитаем относительную частоту событий в разрезе двух групп пользователей - кто смотрел контакты и кто не смотрел. Сделаем выводы.¶
target_user = df.query('is_target == "Yes"')
target_user = target_user.pivot_table(
index = 'event_name',
values = 'event_time',
aggfunc = 'count').reset_index().sort_values(by='event_time', ascending=False)
target_user = target_user.rename(columns={'event_time':'count'})
target_user['ratio'] = round((target_user['count'] / target_user['count'].sum())*100, 2)
target_user
| event_name | count | ratio | |
|---|---|---|---|
| 14 | tips_show | 12698 | 47.74 |
| 2 | contacts_show | 4381 | 16.47 |
| 5 | photos_show | 3497 | 13.15 |
| 0 | advert_open | 1585 | 5.96 |
| 6 | search_1 | 1340 | 5.04 |
| 4 | map | 1066 | 4.01 |
| 1 | contacts_call | 538 | 2.02 |
| 3 | favorites_add | 421 | 1.58 |
| 13 | tips_click | 332 | 1.25 |
| 10 | search_5 | 249 | 0.94 |
| 9 | search_4 | 149 | 0.56 |
| 8 | search_3 | 143 | 0.54 |
| 7 | search_2 | 96 | 0.36 |
| 11 | search_6 | 74 | 0.28 |
| 12 | search_7 | 31 | 0.12 |
no_target_user = df.query('is_target == "No"')
no_target_user = no_target_user.pivot_table(
index = 'event_name',
values = 'event_time',
aggfunc = 'count').reset_index().sort_values(by='event_time', ascending=False)
no_target_user = no_target_user.rename(columns={'event_time':'count'})
no_target_user['ratio'] = round((no_target_user['count'] / no_target_user['count'].sum())*100, 2)
no_target_user
| event_name | count | ratio | |
|---|---|---|---|
| 12 | tips_show | 27194 | 58.51 |
| 3 | photos_show | 5875 | 12.64 |
| 0 | advert_open | 4560 | 9.81 |
| 2 | map | 2694 | 5.80 |
| 4 | search_1 | 2149 | 4.62 |
| 1 | favorites_add | 993 | 2.14 |
| 8 | search_5 | 800 | 1.72 |
| 7 | search_4 | 552 | 1.19 |
| 11 | tips_click | 479 | 1.03 |
| 9 | search_6 | 386 | 0.83 |
| 6 | search_3 | 378 | 0.81 |
| 5 | search_2 | 228 | 0.49 |
| 10 | search_7 | 191 | 0.41 |
freq_events = target_user.merge(no_target_user, on='event_name', how='left')
freq_events.columns = ['event_name', 'target_count', 'target_ratio', 'no_target_count', 'no_target_ratio']
fig = go.Figure(data=[
go.Bar(name='Смотревшие контакты',
x=freq_events['event_name'],
y=freq_events['target_ratio'],
text=freq_events['target_ratio'],
textposition='auto'),
go.Bar(name='Не смотревшие контакты',
x=freq_events['event_name'],
y=freq_events['no_target_ratio'],
text=freq_events['no_target_ratio'],
textposition='auto')
])
fig.update_layout(barmode='group', title='Относительная частота событий в разрезе \
двух групп пользователей, в %')
fig.show()
Вывод:
- Для обоих групп пользователей заметно преобладание действия tips_show среди общего количества действий. Для целевых пользователей действие tips_show занимает 48%, для пользователей, не смотревших контакты доля tips_show составляет 59% от общего числа действий в группе.
- Заметна разница в действиях advert_open. Пользователи, не смотревшие контакты чаще реагируют на рекламные объявления (10% от общего кол-ва действий, против 6% у целевых пользователей);
- Также, заметна разница в действиях map. Целевые пользователи меньше взаимодействуют с картой (4% от общего кол-ва действий против 6% от общего кол-ва действий у не целевых пользователей);
- В остальных дейтсвиях тенденции схожи для целевых и не целевых групп.
Вывод по разделу¶
Определены сценарии поведения пользователей, построена диаграмма Сэнкей. Выделены 4 основных сценария, среди которых:
Сценарий 1. search_1 -- photos_show -- contacts_show. СR сценария 1 = 24%
Сценарий 2. tips_show -- contacts_show. СR сценария 2 = 18%
Сценарий 3. map -- tips_show -- contacts_show. СR сценария 3 = 19%
Cценарий 4. map -- advert_open -- contacts_show. СR сценария 4 = 6%
Рассмотрена относительная частота событий в разрезе двух групп пользователей - кто смотрел контакты и кто не смотрел, сделаны выводы, среди которых:
Для обоих групп пользователей заметно преобладание действия tips_show среди общего количества действий.
Заметна разница в действиях advert_open и map. Среди нецелевых пользователей доля этих действий среди всех выше, чем у целевых пользователей.
Проверим статистические гипотезы¶
Одни пользователи совершают действия tips_show и tips_click , другие — только tips_show . Проверим гипотезу: конверсия в просмотры контактов различается у этих двух групп.¶
Сформируем нулевую и альтернативную гипотезы.
H0: различий в ĸонверсии в просмотры ĸонтаĸтов у групп нет;
H1: ĸонверсия в просмотры ĸонтаĸтов различается у групп пользователей.
Для теста нам необходимо будет проводить сравнения долей генеральных совокупностей по выборкам из них. Разница между пропорциями, наблюдаемыми на выборках, будет нашей статистикой. Будем использовать Z-критерий двух пропорций.
Подготовим данные с ĸонверсией пользователей, совершивших tips_show и tips_click в просмотры ĸонтаĸтов и тольĸо tips_show в просмотры ĸонтаĸтов.
all_tips_show = df.query('event_name == "tips_show"')
all_tips_show_list = all_tips_show['user_id'].to_list()
all_tips_show_count = all_tips_show['user_id'].nunique()
all_tips_show_and_tips_click = df.query('user_id in @all_tips_show_list and event_name == "tips_click"')
all_tips_show_and_tips_click_count = all_tips_show_and_tips_click['user_id'].nunique()
target_tips_show = df.query('user_id in @contacts_yes and event_name == "tips_show"')
target_tips_show_list = target_tips_show['user_id'].to_list()
target_tips_show_count = target_tips_show['user_id'].nunique()
target_tips_show_and_tips_click = df.query('user_id in @target_tips_show_list and event_name == "tips_click"')
target_tips_show_and_tips_click_count = target_tips_show_and_tips_click['user_id'].nunique()
Подчистим пользователей, кто совершил только tips_show. Вычтем из пользователей с tips_show, пользователей с tips_show и tips_click в общей и целевой группах.
all_tips_show_count = all_tips_show_count - all_tips_show_and_tips_click_count
target_tips_show_count = target_tips_show_count - target_tips_show_and_tips_click_count
print('Кол-во пользователей, совершивших tips_show', all_tips_show_count)
print('Кол-во пользователей, совершивших tips_show с целевым действием', target_tips_show_count)
print()
print('Кол-во пользователей, совершивших tips_show и tips_click', all_tips_show_and_tips_click_count)
print('Кол-во пользователей, совершивших tips_show и tips_click с целевым действием', target_tips_show_and_tips_click_count)
Кол-во пользователей, совершивших tips_show 2504 Кол-во пользователей, совершивших tips_show с целевым действием 425 Кол-во пользователей, совершивших tips_show и tips_click 297 Кол-во пользователей, совершивших tips_show и tips_click с целевым действием 91
successes = np.array([target_tips_show_and_tips_click_count, target_tips_show_count])
trials = np.array([all_tips_show_and_tips_click_count, all_tips_show_count])
alpha = .05 # критический уровень статистической значимости
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
p-значение: 9.218316554537864e-09 Отвергаем нулевую гипотезу: между долями есть значимая разница
Вывод:
Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.
Проверим гипотезу: конверсия в просмотры контактов у пользователей кто совершил только tips_show и кто совершил tips_show и favorites_add различается¶
Сформируем нулевую и альтернативную гипотезы.
H0: различий в ĸонверсии в просмотры ĸонтаĸтов у групп нет;
H1: ĸонверсия в просмотры ĸонтаĸтов различается у групп пользователей.
По аналогии с предыдущей проверкой, подготовим данные. Подготовим данные с ĸонверсией пользователей, совершивших tips_show и favorites_add в просмотры ĸонтаĸтов и тольĸо tips_show в просмотры ĸонтаĸтов.
all_tips_show_and_favorites_add = df.query('user_id in @all_tips_show_list and event_name == "favorites_add"')
all_tips_show_and_favorites_add_count = all_tips_show_and_favorites_add['user_id'].nunique()
target_tips_show_and_favorites_add = df.query('user_id in @target_tips_show_list and event_name == "favorites_add"')
target_tips_show_and_favorites_add_count = target_tips_show_and_favorites_add['user_id'].nunique()
all_tips_show_count = all_tips_show_count - all_tips_show_and_favorites_add_count
target_tips_show_count = target_tips_show_count - target_tips_show_and_favorites_add_count
print('Кол-во пользователей, совершивших tips_show', all_tips_show_count)
print('Кол-во пользователей, совершивших tips_show с целевым действием', target_tips_show_count)
print()
print('Кол-во пользователей, совершивших tips_show и favorites_add', all_tips_show_and_favorites_add_count)
print('Кол-во пользователей, совершивших tips_show и favorites_add с целевым действием', target_tips_show_and_favorites_add_count)
Кол-во пользователей, совершивших tips_show 2396 Кол-во пользователей, совершивших tips_show с целевым действием 379 Кол-во пользователей, совершивших tips_show и favorites_add 108 Кол-во пользователей, совершивших tips_show и favorites_add с целевым действием 46
successes = np.array([target_tips_show_and_favorites_add_count, target_tips_show_count])
trials = np.array([all_tips_show_and_favorites_add_count, all_tips_show_count])
alpha = .05 # критический уровень статистической значимости
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
p-значение: 4.150013666048835e-13 Отвергаем нулевую гипотезу: между долями есть значимая разница
Вывод:
Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов у пользователей, добавивших объявление в избранной и не сделавших это различается.
Проверим гипотезу: конверсия в просмотры контактов у пользователей кто добавил объявление в избранное выше, чем у тех кто не добавил.¶
Сформируем нулевую и альтернативную гипотезы.
H0: различий в ĸонверсии в просмотры ĸонтаĸтов у групп нет;
H1: ĸонверсия в просмотры ĸонтаĸтов различается у групп пользователей.
all_us_count = df['user_id'].nunique()
all_favorites_add = df.query('event_name == "favorites_add"')
all_favorites_add_count = all_favorites_add['user_id'].nunique()
target_us = df.query('user_id in @contacts_yes')['user_id'].nunique()
target_favorites_add = df.query('user_id in @contacts_yes and event_name == "favorites_add"')
target_favorites_add_count = target_favorites_add['user_id'].nunique()
all_us_count = all_us_count - all_favorites_add_count
target_us = target_us - target_favorites_add_count
print('Кол-во пользователей, общее:', all_us_count)
print('Кол-во пользователей, совершивших целевое действием', target_us)
print()
print('Кол-во пользователей, совершивших favorites_add', all_favorites_add_count)
print('Кол-во пользователей, совершивших favorites_add с целевым действием', target_favorites_add_count)
Кол-во пользователей, общее: 3942 Кол-во пользователей, совершивших целевое действием 845 Кол-во пользователей, совершивших favorites_add 351 Кол-во пользователей, совершивших favorites_add с целевым действием 136
successes = np.array([target_favorites_add_count, target_us])
trials = np.array([all_favorites_add_count, all_us_count])
alpha = .05 # критический уровень статистической значимости
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
p-значение: 1.3455903058456897e-13 Отвергаем нулевую гипотезу: между долями есть значимая разница
Вывод:
Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.
Общие выводы и рекомендации¶
В начале проекта были поставлены цели:
- Исследовать данные о поведении пользователей, с целью последующего поиска механизмов повышения вовлеченности клиентов приложения.
- Получить на основе поведения пользователей гипотезы о том как можно было бы улучшить приложение с точки зрения пользовательского опыта.
Проект состоял из ряда шагов и этапов. Основные выводы по каждому этапу:
1. На этапе предобработки было сделано следующее:
- Полученные датасеты объединены;
- Проработаны типы данных в столбцах, названия приведены к рабочему виду, убраны повторы в действиях пользователей, проверен период данных.
- Размер датасета после предобработки - 73079 строк, 4 столбца.
2. Проведен исследовательский анализ данных. Основные тезисы:
- Всего уникальных пользователей в датасете: 4293;
- 23% пользователей в датасете совершили целевое действие (981 человек), 77% - нет (3312 человек);
- В датасете содержатся 3 истоника трафика - Яндекс, Google, Другие. Больше всего пользователей приходят из Яндекса (45%).
- Самое распространенное действие - просмотр рекомендованных объявлений.
- При том, что больше пользователей приходит из Яндекса, показатели конверсии в целевое действие у пользователей из Яндекса и Google находятся примерно на одном уровне.
- Наибольшую конверсию из рекомендованных объявлений в целевое действие демонстрируют пользователи, пришедшие из Яндекса (20,46%).
- В среднем, на пользователя приходится 3 сессии, а средняя длина сессии находится в районе 6 минут. При этом, пользователи совершают не более 24 действий за сессию. Среднее значение лежит около 4 действий за сессию.
- Метрика DAU показывает, что в среднем количество активных пользователей в день находится в районе 240 - 300 человек.
3. Изучены основные вопросы исследования, сделан ряд наблюдений:
- Определены сценарии поведения пользователей, построена диаграмма Сэнкей. Выделены 4 основных сценария, среди которых:
- Сценарий 1. search_1 -- photos_show -- contacts_show. СR сценария 1 = 24%
- Сценарий 2. tips_show -- contacts_show. СR сценария 2 = 18%
- Сценарий 3. map -- tips_show -- contacts_show. СR сценария 3 = 19%
- Cценарий 4. map -- advert_open -- contacts_show. СR сценария 4 = 6%
- Для обоих групп пользователей заметно преобладание действия tips_show среди общего количества действий.
Заметна разница в действиях advert_open и map. Среди нецелевых пользователей доля этих действий среди всех выше, чем у целевых пользователей.
4. Проверены статистические гипотезы:
Различается ли конверсия в просмотры контактов у пользователей, которые совершают действия tips_show и tips_click и у пользователей, совершивших только tips_show?
- Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.
Конверсия в просмотры контактов у пользователей кто совершил только tips_show и кто совершил tips_show и favorites_add различается
- Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов у пользователей, добавивших объявление в избранной и не сделавших это различается.
Конверсия в просмотры контактов у пользователей кто добавил объявление в избранное выше, чем у тех кто не добавил.
- Существует статистически значимая разница, позволяющая говорить, что конверсия в просмотры ĸонтаĸтов различается у этих двух групп.
5. Рекомендации для заказчика:
- Основное для пользователя событие - просмотр рекомендованных объявлений, причем как на старте пользовательского пути, так и в дальнейшем. Следует детальнее разобраться в алгоритмах выдачи рекомендаций, при эффективной настройке это поспособствует к увеличению конверсии. Также, следует обратить внимание на действие ticks_clik - кликов по рекомендованным объявлениям заметно мало, следует выяснить почему.
- Необходимо просмотреть действие favorites_add. Конверсия у пользователей, добавляющих и не добавляющих объявление в избранное различается, а значит, поработав с механизмом добавление в избранное, можно простимулировать конверсию (например, присылать напоминания, что товар лежит в избранном)
- Просмотреть выдачу рекламных объявлений. Доля открытия карточки не слишком высока.